page.tsx 76 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
  6. import { Avatar } from '@/components/ui/avatar';
  7. import { ShareModal } from '@/components/share/ShareModal';
  8. import { VideoPlayer } from '@/components/video-player/VideoPlayer';
  9. import { Tool } from '@/components/video-player/AnnotationCanvas';
  10. import { formatTimecode } from '@/lib/format';
  11. const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
  12. const MAX_ANNOTATIONS = 10;
  13. const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass: string; dotClass: string }> = {
  14. PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' },
  15. CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' },
  16. APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' },
  17. REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
  18. };
  19. const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
  20. PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false },
  21. UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true },
  22. PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true },
  23. COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false },
  24. FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false },
  25. UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false },
  26. };
  27. export default function ReviewPage() {
  28. const params = useParams();
  29. const assetId = params.assetId as string;
  30. const { token, user } = useAuth();
  31. const router = useRouter();
  32. const [asset, setAsset] = useState<AssetWithComments | null>(null);
  33. const [comments, setComments] = useState<Comment[]>([]);
  34. const [loading, setLoading] = useState(true);
  35. const [currentTime, setCurrentTime] = useState(0);
  36. const [panelWidth, setPanelWidth] = useState(380);
  37. const [commentPanelCollapsed, setCommentPanelCollapsed] = useState(false);
  38. const [showApproval, setShowApproval] = useState(false);
  39. const [updatingStatus, setUpdatingStatus] = useState(false);
  40. const [newComment, setNewComment] = useState('');
  41. const [submitting, setSubmitting] = useState(false);
  42. const [replyTo, setReplyTo] = useState<Comment | null>(null);
  43. const [showResolved, setShowResolved] = useState(false);
  44. const [showDeleted, setShowDeleted] = useState(false);
  45. const [deletedLoaded, setDeletedLoaded] = useState(false); // true once we've fetched comments with deleted included
  46. const [showShareModal, setShowShareModal] = useState(false);
  47. // Drawing state — lifted to page level
  48. const [drawMode, setDrawMode] = useState(false);
  49. const [drawTool, setDrawTool] = useState<Tool>('arrow');
  50. const [drawColor, setDrawColor] = useState('#ef4444');
  51. const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
  52. // The comment we're annotating (null = annotating the main video, not a specific comment)
  53. const [annotatingComment, setAnnotatingComment] = useState<Comment | null>(null);
  54. // Portrait / landscape detection
  55. const [isPortrait, setIsPortrait] = useState(false);
  56. // ── Side-by-side compare mode ────────────────────────────────────────────
  57. const [compareMode, setCompareMode] = useState(false);
  58. const [compareAsset, setCompareAsset] = useState<Asset | null>(null);
  59. const [showComparePicker, setShowComparePicker] = useState(false);
  60. const [projectAssets, setProjectAssets] = useState<Asset[]>([]);
  61. const [compareMismatch, setCompareMismatch] = useState<string | null>(null);
  62. const [compareComments, setCompareComments] = useState<Comment[]>([]);
  63. const [playing, setPlaying] = useState(false);
  64. // Toggle annotation + speech bubble visibility per video in compare mode
  65. const [showMainAnnotations, setShowMainAnnotations] = useState(true);
  66. const [showCompareAnnotations, setShowCompareAnnotations] = useState(true);
  67. // Video element ref so we can seek directly from comment timestamp clicks
  68. const mainVideoRef = useRef<HTMLVideoElement>(null);
  69. const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
  70. setShowComparePicker(false);
  71. setCompareMismatch(null);
  72. const dur1 = asset?.duration ?? 0;
  73. const dur2 = compareAssetArg.duration ?? 0;
  74. const fps = asset?.fps ?? compareAssetArg.fps ?? 30;
  75. const diffFrames = Math.abs(dur1 - dur2) * fps;
  76. if (diffFrames > 5) {
  77. setCompareMismatch(
  78. `Videos differ by ${Math.round(diffFrames)} frames. Cannot compare — timing mismatch.`
  79. );
  80. // Show mismatch banner but don't enter compare mode
  81. setCompareAsset(compareAssetArg);
  82. setCompareMode(true);
  83. return;
  84. }
  85. setCompareAsset(compareAssetArg);
  86. setCompareMode(true);
  87. // Fetch compare asset's own comments for per-video annotations
  88. if (token) {
  89. commentsApi.list(token, compareAssetArg.id).then(({ comments: cc }) => {
  90. setCompareComments(cc);
  91. }).catch(() => setCompareComments([]));
  92. }
  93. }, [asset, token]);
  94. const handleExitCompare = useCallback(() => {
  95. setCompareMode(false);
  96. setCompareAsset(null);
  97. setCompareMismatch(null);
  98. setCompareComments([]);
  99. }, []);
  100. useEffect(() => {
  101. const mq = window.matchMedia('(orientation: portrait)');
  102. setIsPortrait(mq.matches);
  103. const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches);
  104. mq.addEventListener('change', handler);
  105. return () => mq.removeEventListener('change', handler);
  106. }, []);
  107. const isDraggingRef = useRef(false);
  108. const panelRef = useRef<HTMLDivElement>(null);
  109. const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
  110. // Ref to capture strokes for save callback (avoids closure stale value)
  111. const pendingStrokesRef = useRef<AnnotationData[]>([]);
  112. const annotatingCommentRef = useRef<Comment | null>(null);
  113. // Keep refs in sync with state
  114. useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]);
  115. useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]);
  116. const fps = asset?.fps ?? 30;
  117. // Derive the current user's project role and global role
  118. const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
  119. const globalRole = user?.globalRole;
  120. const isProjectAdmin = currentUserRole === 'ADMIN';
  121. const isProjectOwner = asset?.project.ownerId === user?.id;
  122. const isGlobalAdmin = globalRole === 'ADMIN';
  123. // Only global ADMIN or project owner can see and restore deleted comments
  124. const canSeeDeletedComments = isGlobalAdmin || isProjectOwner;
  125. const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
  126. // ── Poll for transcode progress ───────────────────────────────────────────
  127. const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
  128. const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
  129. useEffect(() => {
  130. if (isTranscoding) {
  131. if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
  132. return;
  133. }
  134. if (pollRef.current) return;
  135. pollRef.current = setInterval(async () => {
  136. if (!token) return;
  137. try {
  138. const { asset: updated } = await assetsApi.getStatus(token, assetId);
  139. setAsset(prev => prev ? { ...prev, ...updated } : prev);
  140. } catch {}
  141. }, 2000);
  142. return () => { if (pollRef.current) clearInterval(pollRef.current); };
  143. }, [token, assetId, isTranscoding]);
  144. // Load asset + comments
  145. const loadData = useCallback(async () => {
  146. if (!token) return;
  147. try {
  148. const [{ asset: a }, { comments: c }] = await Promise.all([
  149. assetsApi.get(token, assetId),
  150. commentsApi.list(token, assetId, { includeDeleted: true }),
  151. ]);
  152. setAsset(a);
  153. setComments(c);
  154. } catch {
  155. router.push('/projects');
  156. } finally {
  157. setLoading(false);
  158. }
  159. }, [token, assetId, router]);
  160. useEffect(() => { loadData(); }, [loadData]);
  161. // ── Panel resize ─────────────────────────────────────────────────────────
  162. const handlePointerMove = useCallback((e: PointerEvent) => {
  163. if (!isDraggingRef.current || !resizeStartRef.current) return;
  164. const dx = e.clientX - resizeStartRef.current.x;
  165. setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w - dx)));
  166. }, []);
  167. const handlePointerUp = useCallback(() => {
  168. isDraggingRef.current = false;
  169. resizeStartRef.current = null;
  170. document.body.style.cursor = '';
  171. }, []);
  172. useEffect(() => {
  173. window.addEventListener('pointermove', handlePointerMove);
  174. window.addEventListener('pointerup', handlePointerUp);
  175. return () => {
  176. window.removeEventListener('pointermove', handlePointerMove);
  177. window.removeEventListener('pointerup', handlePointerUp);
  178. };
  179. }, [handlePointerMove, handlePointerUp]);
  180. const handleResizeStart = (e: React.PointerEvent) => {
  181. e.preventDefault();
  182. isDraggingRef.current = true;
  183. resizeStartRef.current = { x: e.clientX, w: panelWidth };
  184. document.body.style.cursor = 'col-resize';
  185. };
  186. // ── Comment actions ───────────────────────────────────────────────────────
  187. const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => {
  188. if (!token || !content.trim()) return;
  189. setSubmitting(true);
  190. try {
  191. const { comment } = await commentsApi.create(token, assetId, {
  192. content: content.trim(),
  193. timestamp,
  194. annotations,
  195. parentId: replyTo?.id,
  196. });
  197. if (replyTo) {
  198. setComments(prev => prev.map(c =>
  199. c.id === replyTo.id
  200. ? { ...c, replies: [...(c.replies ?? []), comment] }
  201. : c
  202. ));
  203. } else {
  204. setComments(prev => [...prev, comment]);
  205. }
  206. setNewComment('');
  207. setPendingStrokes([]);
  208. setReplyTo(null);
  209. } catch (err) {
  210. alert(err instanceof Error ? err.message : 'Failed to add comment');
  211. } finally {
  212. setSubmitting(false);
  213. }
  214. };
  215. const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
  216. if (!token) return;
  217. try {
  218. const { comment } = await commentsApi.resolve(token, commentId, action);
  219. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  220. } catch (err) {
  221. alert(err instanceof Error ? err.message : 'Failed to update comment');
  222. }
  223. };
  224. const handleRequestResolve = async (commentId: string) => {
  225. if (!token) return;
  226. try {
  227. const { comment } = await commentsApi.requestResolve(token, commentId);
  228. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  229. } catch (err) {
  230. alert(err instanceof Error ? err.message : 'Failed to request resolve');
  231. }
  232. };
  233. const handleDeleteComment = async (commentId: string) => {
  234. if (!token) return;
  235. // Soft delete — just mark hidden, owner can restore
  236. try {
  237. await commentsApi.delete(token, commentId);
  238. setComments(prev => prev.map(c =>
  239. c.id === commentId ? { ...c, deleted: true } : c
  240. ));
  241. } catch {
  242. alert('Failed to hide comment');
  243. }
  244. };
  245. const handleRestoreComment = async (commentId: string) => {
  246. if (!token) return;
  247. try {
  248. const { comment } = await commentsApi.restoreComment(token, commentId);
  249. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  250. } catch {
  251. alert('Failed to restore comment');
  252. }
  253. };
  254. // ── Annotation actions ─────────────────────────────────────────────────────
  255. // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
  256. const handleAddAnnotationClick = (comment: Comment) => {
  257. const existingCount = comment.annotations?.length ?? 0;
  258. if (existingCount >= MAX_ANNOTATIONS) {
  259. alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
  260. return;
  261. }
  262. setPendingStrokes([]);
  263. setAnnotatingComment(comment);
  264. setDrawMode(true);
  265. };
  266. // Each completed stroke is added to pendingStrokes
  267. const handleStrokeComplete = (stroke: AnnotationData) => {
  268. setPendingStrokes(prev => {
  269. const next = [...prev, stroke];
  270. if (next.length >= MAX_ANNOTATIONS) {
  271. setDrawMode(false);
  272. }
  273. return next;
  274. });
  275. };
  276. // Save pending strokes as annotation on the parent comment (no separate reply)
  277. const handleSaveAnnotations = () => {
  278. const strokes = pendingStrokesRef.current;
  279. const parent = annotatingCommentRef.current;
  280. if (!token || !parent || strokes.length === 0) {
  281. setPendingStrokes([]);
  282. setDrawMode(false);
  283. setAnnotatingComment(null);
  284. return;
  285. }
  286. setSubmitting(true);
  287. setPendingStrokes([]);
  288. setDrawMode(false);
  289. setAnnotatingComment(null);
  290. commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
  291. setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
  292. }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
  293. };
  294. // Discard pending strokes
  295. const handleUndoAnnotations = () => {
  296. setPendingStrokes([]);
  297. setDrawMode(false);
  298. setAnnotatingComment(null);
  299. };
  300. // Delete a single annotation from a comment (owner only)
  301. const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => {
  302. if (!token) return;
  303. try {
  304. const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations);
  305. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  306. } catch {
  307. alert('Failed to delete annotation');
  308. }
  309. };
  310. const handleStatusUpdate = async (status: string) => {
  311. if (!token) return;
  312. setUpdatingStatus(true);
  313. try {
  314. const { asset: updated } = await assetsApi.updateStatus(token, assetId, status);
  315. setAsset(prev => prev ? { ...prev, status: updated.status } : prev);
  316. setShowApproval(false);
  317. } catch {
  318. alert('Failed to update status');
  319. } finally {
  320. setUpdatingStatus(false);
  321. }
  322. };
  323. const handleTimeUpdate = useCallback((time: number) => {
  324. setCurrentTime(time);
  325. }, []);
  326. const handleCommentSeek = useCallback((comment: Comment) => {
  327. const time = comment.timestamp ?? 0;
  328. setCurrentTime(time);
  329. if (mainVideoRef.current) {
  330. mainVideoRef.current.pause();
  331. mainVideoRef.current.currentTime = time;
  332. }
  333. }, []);
  334. const status = asset?.status ?? 'PENDING_REVIEW';
  335. const statusCfg = STATUS_CONFIG[status];
  336. const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
  337. const videoUrl = asset?.hlsPath
  338. ? `${API_BASE}/uploads${asset.hlsPath}`
  339. : asset
  340. ? `${API_BASE}/uploads/${asset.filePath}`
  341. : '';
  342. const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
  343. const visibleComments = comments.filter(c =>
  344. (showDeleted || !c.deleted) && (showResolved || !c.resolved)
  345. );
  346. const deletedCount = comments.filter(c => c.deleted).length;
  347. // Seek to previous/next comment (defined here so they can reference visibleComments)
  348. const handlePrevComment = useCallback(() => {
  349. const ts = visibleComments
  350. .filter(c => c.timestamp != null)
  351. .map(c => c.timestamp as number)
  352. .sort((a, b) => b - a);
  353. const prev = ts.find(t => t < currentTime - 0.3);
  354. if (prev !== undefined) handleCommentSeek({ timestamp: prev } as Comment);
  355. }, [visibleComments, currentTime, handleCommentSeek]);
  356. const handleNextComment = useCallback(() => {
  357. const ts = visibleComments
  358. .filter(c => c.timestamp != null)
  359. .map(c => c.timestamp as number)
  360. .sort((a, b) => a - b);
  361. const next = ts.find(t => t > currentTime + 0.3);
  362. if (next !== undefined) handleCommentSeek({ timestamp: next } as Comment);
  363. }, [visibleComments, currentTime, handleCommentSeek]);
  364. // Only main comments (not replies, not deleted) have annotations that should show on the video
  365. const visibleAnnotations = visibleComments
  366. .filter(c => !c.deleted)
  367. .flatMap(c =>
  368. (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
  369. );
  370. // Annotations for the compare video — independent per-video data
  371. const compareVisibleComments = compareComments.filter(c => !c.deleted && (showResolved || !c.resolved));
  372. const compareVisibleAnnotations = compareVisibleComments
  373. .filter(c => !c.deleted)
  374. .flatMap(c =>
  375. (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
  376. );
  377. if (loading) {
  378. return (
  379. <div className="h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  380. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  381. <div className="w-5 h-5 rounded-full animate-spin"
  382. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  383. <span className="text-sm">Loading review…</span>
  384. </div>
  385. </div>
  386. );
  387. }
  388. if (!asset) return null;
  389. return (
  390. <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
  391. {/* ── Top bar ──────────────────────────────────────────── */}
  392. <header className="h-12 flex items-center px-4 gap-3 shrink-0"
  393. style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)', zIndex: 50 }}>
  394. <button
  395. onClick={() => router.push(`/projects/${asset.projectId}`)}
  396. className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
  397. style={{ color: 'var(--text-muted)' }}
  398. >
  399. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  400. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  401. </svg>
  402. <span className="hidden sm:inline">Back</span>
  403. </button>
  404. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  405. <div className="flex-1 min-w-0">
  406. <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h1>
  407. </div>
  408. <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
  409. {asset.project?.name}
  410. </span>
  411. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  412. {/* Download */}
  413. <a
  414. href={`${API_BASE}/uploads/${asset.filePath}`}
  415. download={asset.originalFilename ?? asset.filename}
  416. className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
  417. style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}
  418. title="Download original video"
  419. >
  420. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  421. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
  422. </svg>
  423. <span className="hidden sm:inline">Download</span>
  424. </a>
  425. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  426. {/* Share */}
  427. <button
  428. onClick={() => setShowShareModal(true)}
  429. className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
  430. style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}
  431. title="Share video"
  432. >
  433. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  434. <path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
  435. </svg>
  436. <span className="hidden sm:inline">Share</span>
  437. </button>
  438. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  439. {/* Compare mode toggle */}
  440. <button
  441. onClick={() => {
  442. if (compareMode) {
  443. handleExitCompare();
  444. } else {
  445. setShowComparePicker(true);
  446. if (token && asset) {
  447. assetsApi.list(token, asset.projectId).then(({ assets }) => {
  448. setProjectAssets(assets.filter(a => a.id !== assetId && a.transcodeStatus === 'COMPLETED'));
  449. }).catch(() => {});
  450. }
  451. }
  452. }}
  453. className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0 ${
  454. compareMode
  455. ? 'bg-indigo-600 text-white'
  456. : ''
  457. }`}
  458. style={!compareMode ? { color: '#818CF8', background: 'rgba(129,140,248,0.10)' } : {}}
  459. title="Side-by-side comparison"
  460. >
  461. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  462. <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
  463. </svg>
  464. <span className="hidden sm:inline">{compareMode ? 'Exit Compare' : 'Compare'}</span>
  465. </button>
  466. {/* Status selector */}
  467. <div className="relative shrink-0">
  468. <button
  469. onClick={() => setShowApproval(v => !v)}
  470. className="flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-all"
  471. style={{ background: statusCfg.bgClass.replace('badge-', 'rgba(').replace('warning', '245,158,11,0.15)').replace('success', '34,197,94,0.15)').replace('danger', '239,68,68,0.15)'), color: statusCfg.colorClass }}
  472. >
  473. <span className={`status-dot ${statusCfg.dotClass}`} />
  474. <span className="hidden sm:inline">{statusCfg.label}</span>
  475. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  476. <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
  477. </svg>
  478. </button>
  479. {showApproval && (
  480. <>
  481. <div className="fixed inset-0 z-40" onClick={() => setShowApproval(false)} />
  482. <div className="absolute right-0 top-full mt-2 z-50 rounded-xl overflow-hidden"
  483. style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-panel)', minWidth: '200px' }}>
  484. {Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
  485. <button
  486. key={key}
  487. onClick={() => handleStatusUpdate(key)}
  488. disabled={updatingStatus}
  489. className="w-full flex items-center gap-2.5 px-4 py-2.5 text-xs transition-colors hover:bg-white/5"
  490. style={{ color: key === status ? cfg.colorClass : 'var(--text)' }}
  491. >
  492. <span className={`status-dot ${cfg.dotClass}`} />
  493. <span className="flex-1 text-left">{cfg.label}</span>
  494. {key === status && (
  495. <svg className="w-3.5 h-3.5" style={{ color: '#6366F1' }} fill="currentColor" viewBox="0 0 20 20">
  496. <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
  497. </svg>
  498. )}
  499. </button>
  500. ))}
  501. </div>
  502. </>
  503. )}
  504. </div>
  505. </header>
  506. {/* ── Compare picker modal ─────────────────────────────────────────────── */}
  507. {showComparePicker && (
  508. <>
  509. <div className="fixed inset-0 z-50" onClick={() => setShowComparePicker(false)} />
  510. <div
  511. className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-2xl overflow-hidden w-full max-w-md"
  512. style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-modal)' }}
  513. >
  514. <div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  515. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Select video to compare</h2>
  516. <button onClick={() => setShowComparePicker(false)} className="w-7 h-7 flex items-center justify-center rounded-lg transition-colors hover:bg-white/10"
  517. style={{ color: 'var(--text-muted)' }}>
  518. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  519. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  520. </svg>
  521. </button>
  522. </div>
  523. <div className="p-2 max-h-80 overflow-y-auto">
  524. {projectAssets.length === 0 ? (
  525. <p className="text-sm text-center py-8" style={{ color: 'var(--text-muted)' }}>
  526. No other completed videos in this project.
  527. </p>
  528. ) : (
  529. projectAssets.map(a => (
  530. <button
  531. key={a.id}
  532. onClick={() => handleCompareSelect(a)}
  533. className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors hover:bg-white/5"
  534. >
  535. {a.thumbnail ? (
  536. <img src={`${API_BASE}/uploads/${a.thumbnail}`} className="w-16 h-10 rounded-lg object-cover shrink-0" alt={a.title} />
  537. ) : (
  538. <div className="w-16 h-10 rounded-lg shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
  539. <svg className="w-5 h-5" style={{ color: 'rgba(255,255,255,0.2)' }} fill="currentColor" viewBox="0 0 24 24">
  540. <path d="M8 5v14l11-7z" />
  541. </svg>
  542. </div>
  543. )}
  544. <div className="flex-1 min-w-0">
  545. <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{a.title}</p>
  546. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  547. {a.duration ? `${Math.floor(a.duration / 60)}:${Math.floor(a.duration % 60).toString().padStart(2, '0')}` : '—'}
  548. {' · '}
  549. {a.filename}
  550. </p>
  551. </div>
  552. </button>
  553. ))
  554. )}
  555. </div>
  556. </div>
  557. </>
  558. )}
  559. {/* ── Body ───────────────────────────────────────────── */}
  560. {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
  561. <div
  562. className="flex flex-1 overflow-hidden"
  563. style={isPortrait
  564. ? { flexDirection: 'column', overflowY: 'auto' }
  565. : { flexDirection: 'row' }}
  566. >
  567. {/* Video area */}
  568. <div
  569. className="overflow-y-auto p-3 sm:p-4 flex flex-col gap-3 min-w-0"
  570. style={isPortrait
  571. ? { flex: 'none', width: '100%', minHeight: '45vh' }
  572. : { flex: 1, overflowY: 'auto' }}
  573. >
  574. {/* ── Side-by-side compare layout ───────────────────────── */}
  575. {compareMode ? (
  576. <div className="flex gap-2 w-full flex-1 min-h-0">
  577. {/* Main video + its comments */}
  578. <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
  579. {/* Annotation toggle */}
  580. <div className="flex items-center gap-2 mb-1 px-1">
  581. <button
  582. onClick={() => setShowMainAnnotations(v => !v)}
  583. className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
  584. style={showMainAnnotations
  585. ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
  586. : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
  587. title={showMainAnnotations ? 'Hide annotations' : 'Show annotations'}
  588. >
  589. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  590. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  591. </svg>
  592. Annot.
  593. </button>
  594. </div>
  595. <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
  596. {asset.title}
  597. </div>
  598. <div className="flex-1 min-h-0 flex flex-col gap-0">
  599. <VideoPlayer
  600. src={videoUrl}
  601. mimeType={asset.mimeType}
  602. fps={fps}
  603. comments={showMainAnnotations ? allComments : []}
  604. visibleAnnotations={showMainAnnotations ? visibleAnnotations : []}
  605. drawMode={drawMode}
  606. drawTool={drawTool}
  607. drawColor={drawColor}
  608. onDrawModeChange={setDrawMode}
  609. onDrawToolChange={setDrawTool}
  610. onDrawColorChange={setDrawColor}
  611. pendingStrokes={pendingStrokes}
  612. onStrokeComplete={handleStrokeComplete}
  613. onTimeUpdate={handleTimeUpdate}
  614. onCommentClick={handleCommentSeek}
  615. onPlayingChange={setPlaying}
  616. onTimelineSeek={handleTimeUpdate}
  617. externalCurrentTime={currentTime}
  618. externalPlaying={playing}
  619. videoRef={mainVideoRef}
  620. onPrevComment={handlePrevComment}
  621. onNextComment={handleNextComment}
  622. thumbnailSrc={videoUrl}
  623. thumbnailMimeType={asset.mimeType}
  624. />
  625. {/* Comments below main video — full available height */}
  626. <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
  627. <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  628. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  629. Comments
  630. </span>
  631. <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  632. {visibleComments.length}
  633. </span>
  634. <span className="font-mono text-[11px] ml-auto" style={{ color: '#818CF8' }}>
  635. {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
  636. </span>
  637. </div>
  638. <div className="flex-1 overflow-y-auto scroll-area">
  639. {visibleComments.length === 0 ? (
  640. <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
  641. ) : (
  642. visibleComments.map(comment => (
  643. <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
  644. <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="xs" />
  645. <div className="flex-1 min-w-0">
  646. <div className="flex items-center gap-1.5 mb-0.5">
  647. <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
  648. {comment.timestamp != null && (
  649. <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
  650. {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
  651. </span>
  652. )}
  653. </div>
  654. <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
  655. </div>
  656. </div>
  657. ))
  658. )}
  659. </div>
  660. </div>
  661. </div>
  662. </div>
  663. {/* Compare video + its comments — only show when durations match */}
  664. {compareAsset && !compareMismatch && (
  665. <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
  666. {/* Annotation toggle */}
  667. <div className="flex items-center gap-2 mb-1 px-1">
  668. <button
  669. onClick={() => setShowCompareAnnotations(v => !v)}
  670. className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
  671. style={showCompareAnnotations
  672. ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
  673. : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
  674. title={showCompareAnnotations ? 'Hide annotations' : 'Show annotations'}
  675. >
  676. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  677. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  678. </svg>
  679. Annot.
  680. </button>
  681. </div>
  682. <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
  683. {compareAsset.title}
  684. </div>
  685. <div className="flex-1 min-h-0 flex flex-col gap-0">
  686. <VideoPlayer
  687. src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
  688. mimeType={compareAsset.mimeType}
  689. fps={compareAsset.fps ?? 30}
  690. comments={showCompareAnnotations ? compareComments : []}
  691. visibleAnnotations={showCompareAnnotations ? compareVisibleAnnotations : []}
  692. drawMode={false}
  693. drawTool={drawTool}
  694. drawColor={drawColor}
  695. onDrawModeChange={() => {}}
  696. onDrawToolChange={() => {}}
  697. onDrawColorChange={() => {}}
  698. pendingStrokes={[]}
  699. onStrokeComplete={() => {}}
  700. onTimeUpdate={() => {}}
  701. onCommentClick={() => {}}
  702. isComparePlayer={true}
  703. externalCurrentTime={currentTime}
  704. externalPlaying={playing}
  705. thumbnailSrc={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
  706. thumbnailMimeType={compareAsset.mimeType}
  707. />
  708. {/* Comments below compare video — full available height */}
  709. <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
  710. <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  711. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  712. Comments
  713. </span>
  714. <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  715. {compareVisibleComments.length}
  716. </span>
  717. </div>
  718. <div className="flex-1 overflow-y-auto scroll-area">
  719. {compareVisibleComments.length === 0 ? (
  720. <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
  721. ) : (
  722. compareVisibleComments.map(comment => (
  723. <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
  724. <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="xs" />
  725. <div className="flex-1 min-w-0">
  726. <div className="flex items-center gap-1.5 mb-0.5">
  727. <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
  728. {comment.timestamp != null && (
  729. <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
  730. {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
  731. </span>
  732. )}
  733. </div>
  734. <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
  735. </div>
  736. </div>
  737. ))
  738. )}
  739. </div>
  740. </div>
  741. </div>
  742. </div>
  743. )}
  744. </div>
  745. ) : (
  746. /* ── Normal single-video layout ─────────────────────────── */
  747. <VideoPlayer
  748. src={videoUrl}
  749. mimeType={asset.mimeType}
  750. fps={fps}
  751. comments={allComments}
  752. visibleAnnotations={visibleAnnotations}
  753. drawMode={drawMode}
  754. drawTool={drawTool}
  755. drawColor={drawColor}
  756. onDrawModeChange={setDrawMode}
  757. onDrawToolChange={setDrawTool}
  758. onDrawColorChange={setDrawColor}
  759. pendingStrokes={pendingStrokes}
  760. onStrokeComplete={handleStrokeComplete}
  761. onTimeUpdate={handleTimeUpdate}
  762. onCommentClick={handleCommentSeek}
  763. onPlayingChange={setPlaying}
  764. videoRef={mainVideoRef}
  765. onPrevComment={handlePrevComment}
  766. onNextComment={handleNextComment}
  767. thumbnailSrc={videoUrl}
  768. thumbnailMimeType={asset.mimeType}
  769. />
  770. )}
  771. {/* ── Compare mismatch warning ─────────────────────────── */}
  772. {compareMode && compareMismatch && (
  773. <div className="rounded-xl px-4 py-3 text-xs flex items-center gap-3"
  774. style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.25)', color: '#FCD34D' }}>
  775. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  776. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  777. </svg>
  778. <span className="flex-1">{compareMismatch}</span>
  779. <button
  780. onClick={handleExitCompare}
  781. className="shrink-0 px-2 py-1 rounded-md transition-colors"
  782. style={{ background: 'rgba(251,191,36,0.15)', color: '#FCD34D' }}
  783. >
  784. Cancel
  785. </button>
  786. </div>
  787. )}
  788. {/* Transcode status overlay — shown when video is not ready */}
  789. {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
  790. <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
  791. style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
  792. {transcodeCfg.spinner ? (
  793. <div className="w-8 h-8 rounded-full animate-spin shrink-0"
  794. style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
  795. ) : asset.transcodeStatus === 'FAILED' ? (
  796. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  797. style={{ background: 'rgba(248,113,113,0.15)' }}>
  798. <svg className="w-4 h-4" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  799. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  800. </svg>
  801. </div>
  802. ) : (
  803. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  804. style={{ background: 'rgba(251,191,36,0.15)' }}>
  805. <svg className="w-4 h-4" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  806. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  807. </svg>
  808. </div>
  809. )}
  810. <div className="flex-1 min-w-0">
  811. <div className="flex items-center gap-2 mb-1">
  812. <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
  813. {transcodeCfg.label}
  814. </span>
  815. {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
  816. <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
  817. {asset.transcodeProgress}%
  818. </span>
  819. )}
  820. </div>
  821. {asset.transcodeStatus === 'PROCESSING' && (
  822. <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
  823. <div
  824. className="h-full rounded-full transition-all duration-500"
  825. style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
  826. />
  827. </div>
  828. )}
  829. {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
  830. <p className="text-xs mt-1" style={{ color: '#F87171' }}>
  831. {asset.transcodeError}
  832. </p>
  833. )}
  834. {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
  835. <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
  836. {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
  837. </p>
  838. )}
  839. {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
  840. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  841. Converting from {asset.codec.toUpperCase()} → H.264/AAC
  842. </p>
  843. )}
  844. {asset.transcodeStatus === 'UPLOADING' && (
  845. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  846. Video uploaded — queued for processing
  847. </p>
  848. )}
  849. </div>
  850. </div>
  851. )}
  852. {/* Keyboard shortcuts */}
  853. {!compareMode && (
  854. <div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" style={{ color: 'var(--text-subtle)' }}>
  855. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
  856. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> ±1 frame <kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧→</kbd> ±1s</span>
  857. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw mode</span>
  858. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
  859. <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
  860. </div>
  861. )}
  862. </div>
  863. {/* Resize handle — visible grip bar with 3-dot pattern, wider hit area */}
  864. {!isPortrait && !compareMode && !commentPanelCollapsed && (
  865. <div
  866. onPointerDown={handleResizeStart}
  867. className="shrink-0 group relative cursor-col-resize select-none"
  868. style={{ width: 12 }}
  869. title="Drag to resize"
  870. >
  871. {/* Invisible wide hit area (wider than visual) */}
  872. <div className="absolute inset-y-0" style={{ width: 24, left: -6 }} />
  873. {/* Visual grip bar */}
  874. <div className="absolute inset-y-0 left-1/2 -translate-x-1/2 flex flex-col items-center justify-center gap-1.5" style={{ width: 2 }}>
  875. {[0, 1, 2].map(i => (
  876. <div
  877. key={i}
  878. className="w-1 rounded-full transition-colors"
  879. style={{
  880. height: 16,
  881. background: 'rgba(255,255,255,0.18)',
  882. }}
  883. />
  884. ))}
  885. </div>
  886. {/* Highlight on drag */}
  887. <div
  888. className="absolute inset-y-0 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity"
  889. style={{ width: 2, background: 'rgba(99,102,241,0.5)' }}
  890. />
  891. </div>
  892. )}
  893. {/* Floating expand button when panel is collapsed */}
  894. {!isPortrait && !compareMode && commentPanelCollapsed && (
  895. <button
  896. onClick={() => setCommentPanelCollapsed(false)}
  897. className="shrink-0 flex items-center justify-center w-8 self-stretch rounded-l-lg transition-all hover:bg-white/10 active:scale-95"
  898. style={{ background: 'rgba(10,11,20,0.90)', borderLeft: '1px solid rgba(255,255,255,0.06)' }}
  899. title="Expand comments panel"
  900. >
  901. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-muted)' }}>
  902. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  903. </svg>
  904. </button>
  905. )}
  906. {/* ── Comment panel — hidden in compare mode (comments are below each video) ── */}
  907. {!compareMode && (
  908. <div
  909. ref={panelRef}
  910. className={`flex flex-col shrink-0 transition-all duration-300 ease-in-out ${commentPanelCollapsed && !isPortrait ? 'comment-panel-collapsed' : ''}`}
  911. style={isPortrait
  912. ? {
  913. flex: 1,
  914. width: '100%',
  915. minHeight: '55vh',
  916. background: 'rgba(10,11,20,0.98)',
  917. borderTop: '1px solid rgba(255,255,255,0.06)',
  918. }
  919. : {
  920. width: panelWidth,
  921. background: 'rgba(10,11,20,0.98)',
  922. borderLeft: '1px solid rgba(255,255,255,0.06)',
  923. }}
  924. >
  925. {/* Panel header */}
  926. <div className="px-3 sm:px-4 py-2.5 sm:py-3 flex items-center justify-between shrink-0"
  927. style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  928. <div className="flex items-center gap-2">
  929. <h2 className="text-[13px] sm:text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
  930. <span className="text-xs px-1.5 py-0.5 rounded-full"
  931. style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  932. {comments.length}
  933. {comments.length !== visibleComments.length && (
  934. <span className="ml-1 opacity-60">/ {visibleComments.length}</span>
  935. )}
  936. </span>
  937. </div>
  938. <div className="flex items-center gap-2">
  939. <span className="font-mono text-[11px] sm:text-xs hidden sm:inline" style={{ color: '#818CF8' }}>
  940. {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
  941. </span>
  942. <button
  943. onClick={() => setShowResolved(v => !v)}
  944. className={`text-[11px] px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
  945. style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
  946. >
  947. {showResolved ? 'Hide resolved' : 'Show resolved'}
  948. </button>
  949. {canSeeDeletedComments && deletedCount > 0 && (
  950. <button
  951. onClick={() => setShowDeleted(v => !v)}
  952. className={`text-[11px] px-2 py-0.5 rounded-md transition-colors ${showDeleted ? 'bg-red-600 text-white' : ''}`}
  953. style={!showDeleted ? { background: 'rgba(239,68,68,0.12)', color: '#FCA5A5' } : {}}
  954. title="Toggle deleted comments"
  955. >
  956. {showDeleted ? 'Hide deleted' : `${deletedCount} deleted`}
  957. </button>
  958. )}
  959. <button
  960. onClick={() => setCommentPanelCollapsed(v => !v)}
  961. className="text-[11px] px-2 py-0.5 rounded-md transition-colors"
  962. style={commentPanelCollapsed
  963. ? { background: 'rgba(99,102,241,0.20)', color: '#818CF8' }
  964. : { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}
  965. title={commentPanelCollapsed ? 'Expand comments panel' : 'Collapse comments panel'}
  966. >
  967. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  968. {commentPanelCollapsed ? (
  969. // Chevron left — clicking expands the panel (panel slides in from right)
  970. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  971. ) : (
  972. // Chevron right — clicking collapses the panel (panel slides out to right)
  973. <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
  974. )}
  975. </svg>
  976. </button>
  977. {compareMode && (
  978. <span className="text-[11px] px-2 py-0.5 rounded-md" style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
  979. Compare mode
  980. </span>
  981. )}
  982. </div>
  983. </div>
  984. {/* Drawing mode banner */}
  985. {drawMode && (
  986. <div className="px-4 py-2 shrink-0 flex items-center gap-2"
  987. style={{ background: 'rgba(59,130,246,0.12)', borderBottom: '1px solid rgba(59,130,246,0.2)' }}>
  988. <svg className="w-4 h-4 shrink-0" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  989. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  990. </svg>
  991. <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
  992. {annotatingComment
  993. ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
  994. : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
  995. </span>
  996. <div className="flex items-center gap-1.5">
  997. <button
  998. onClick={handleUndoAnnotations}
  999. className="text-xs px-2 py-0.5 rounded transition-colors"
  1000. style={{ background: 'rgba(239,68,68,0.15)', color: '#FCA5A5' }}
  1001. >
  1002. Undo all
  1003. </button>
  1004. <button
  1005. onClick={handleSaveAnnotations}
  1006. disabled={submitting || pendingStrokes.length === 0}
  1007. className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
  1008. style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
  1009. >
  1010. {submitting ? 'Saving…' : 'Save'}
  1011. </button>
  1012. </div>
  1013. </div>
  1014. )}
  1015. {/* Comment list */}
  1016. <div className="flex-1 overflow-y-auto scroll-area">
  1017. {visibleComments.length === 0 ? (
  1018. <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
  1019. <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
  1020. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  1021. <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  1022. <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
  1023. </svg>
  1024. </div>
  1025. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
  1026. <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  1027. Add a comment below or click <strong>Add annotation</strong> on an existing comment
  1028. </p>
  1029. </div>
  1030. ) : (
  1031. <div>
  1032. {visibleComments.map(comment => (
  1033. <CommentItem
  1034. key={comment.id}
  1035. comment={comment}
  1036. currentUserId={user?.id ?? ''}
  1037. fps={fps}
  1038. duration={asset?.duration ?? 0}
  1039. canComment={canComment}
  1040. isProjectAdmin={isProjectAdmin}
  1041. isProjectOwner={isProjectOwner ?? false}
  1042. isGlobalAdmin={isGlobalAdmin}
  1043. onTimestampClick={handleCommentSeek}
  1044. onReply={() => { setReplyTo(comment); }}
  1045. onResolve={(action) => handleResolve(comment.id, action)}
  1046. onRequestResolve={() => handleRequestResolve(comment.id)}
  1047. onDeleteSelf={() => handleDeleteComment(comment.id)}
  1048. onDelete={(id) => handleDeleteComment(id)}
  1049. onAddAnnotation={() => handleAddAnnotationClick(comment)}
  1050. onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
  1051. onRestore={handleRestoreComment}
  1052. />
  1053. ))}
  1054. </div>
  1055. )}
  1056. </div>
  1057. {/* New comment / reply input */}
  1058. <div className="shrink-0 p-4"
  1059. style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
  1060. {replyTo && (
  1061. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  1062. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1063. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1064. </svg>
  1065. Replying to {replyTo.user?.name}
  1066. <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1067. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1068. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1069. </svg>
  1070. </button>
  1071. </div>
  1072. )}
  1073. {/* Pending strokes indicator */}
  1074. {pendingStrokes.length > 0 && (
  1075. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: '#818CF8' }}>
  1076. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1077. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1078. </svg>
  1079. {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
  1080. {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
  1081. <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
  1082. </div>
  1083. )}
  1084. <form
  1085. onSubmit={e => {
  1086. e.preventDefault();
  1087. if (newComment.trim() || pendingStrokes.length > 0) {
  1088. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  1089. }
  1090. }}
  1091. className="flex gap-2"
  1092. >
  1093. <Avatar name={user?.name ?? 'U'} src={user?.avatarUrl} size="sm" />
  1094. <div className="flex-1 flex gap-2">
  1095. <textarea
  1096. className="input flex-1"
  1097. value={compareMode ? '' : newComment}
  1098. onChange={e => setNewComment(e.target.value)}
  1099. placeholder={compareMode ? 'Comments disabled in compare mode' : replyTo ? 'Write a reply…' : 'Add a comment…'}
  1100. disabled={compareMode}
  1101. readOnly={compareMode}
  1102. rows={1}
  1103. style={{ resize: 'none', overflow: 'hidden' }}
  1104. onKeyDown={e => {
  1105. if (e.key === 'Enter' && !e.shiftKey) {
  1106. e.preventDefault();
  1107. if (newComment.trim() || pendingStrokes.length > 0) {
  1108. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  1109. }
  1110. }
  1111. }}
  1112. />
  1113. <button
  1114. type="submit"
  1115. disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
  1116. className="btn btn-primary btn-sm px-3"
  1117. >
  1118. {submitting ? (
  1119. <div className="w-3.5 h-3.5 rounded-full animate-spin"
  1120. style={{ borderColor: '#fff', borderTopColor: 'transparent' }} />
  1121. ) : (
  1122. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1123. <path strokeLinecap="round" strokeLinejoin="round" d="M6 12h12M6 12l4-4M6 12l4 4" />
  1124. </svg>
  1125. )}
  1126. </button>
  1127. </div>
  1128. </form>
  1129. </div>
  1130. </div>
  1131. )}
  1132. </div>
  1133. {/* Share modal */}
  1134. {showShareModal && asset && (
  1135. <ShareModal
  1136. assetId={asset.id}
  1137. assetTitle={asset.title}
  1138. onClose={() => setShowShareModal(false)}
  1139. />
  1140. )}
  1141. </div>
  1142. );
  1143. }
  1144. // ── CommentItem ─────────────────────────────────────────────────────────────
  1145. function CommentItem({
  1146. comment,
  1147. currentUserId,
  1148. fps,
  1149. duration,
  1150. canComment,
  1151. isProjectAdmin,
  1152. isProjectOwner,
  1153. isGlobalAdmin,
  1154. onTimestampClick,
  1155. onReply,
  1156. onResolve,
  1157. onRequestResolve,
  1158. onDeleteSelf,
  1159. onDelete,
  1160. onAddAnnotation,
  1161. onDeleteAnnotation,
  1162. onRestore,
  1163. }: {
  1164. comment: Comment;
  1165. currentUserId: string;
  1166. fps: number;
  1167. duration: number;
  1168. canComment: boolean | undefined;
  1169. isProjectAdmin: boolean;
  1170. isProjectOwner: boolean;
  1171. isGlobalAdmin: boolean;
  1172. onTimestampClick: (c: Comment) => void;
  1173. onReply: () => void;
  1174. onResolve: (action: 'approve' | 'reject') => void;
  1175. onRequestResolve: () => void;
  1176. onDeleteSelf: () => void;
  1177. onDelete: (id: string) => void;
  1178. onAddAnnotation: () => void;
  1179. onDeleteAnnotation: (annotations: AnnotationData[]) => void;
  1180. onRestore: (id: string) => void;
  1181. }) {
  1182. const isOwner = comment.userId === currentUserId;
  1183. const isCommentAuthor = comment.userId === currentUserId;
  1184. const name = comment.user?.name ?? 'Unknown';
  1185. const isReply = !!comment.parentId;
  1186. const annotations = comment.annotations ?? [];
  1187. const canAddMore = annotations.length < MAX_ANNOTATIONS;
  1188. const isDeleted = !!comment.deleted;
  1189. // Only global ADMIN or project owner can restore a deleted comment
  1190. const canRestore = isDeleted && (isProjectOwner || isGlobalAdmin);
  1191. // Resolve state machine
  1192. const isResolved = comment.resolveStatus === 'RESOLVED';
  1193. const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
  1194. const canApprove = isCommentAuthor || isProjectAdmin;
  1195. const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
  1196. const canReopen = isResolved && canApprove;
  1197. return (
  1198. <div
  1199. className="p-4 animate-fade-in"
  1200. style={{
  1201. opacity: isDeleted ? 0.45 : isResolved ? 0.55 : 1,
  1202. paddingLeft: isReply ? '2.5rem' : undefined,
  1203. borderLeft: isDeleted ? '2px solid rgba(239,68,68,0.3)' : undefined,
  1204. }}
  1205. >
  1206. <div className="flex gap-2.5">
  1207. <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="sm" />
  1208. <div className="flex-1 min-w-0">
  1209. {/* Meta row */}
  1210. <div className="flex items-center gap-2 mb-1 flex-wrap">
  1211. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
  1212. {comment.timestamp != null && (
  1213. <button
  1214. onClick={() => onTimestampClick(comment)}
  1215. className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
  1216. style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
  1217. >
  1218. {formatTimecode(comment.timestamp, fps, duration)}
  1219. </button>
  1220. )}
  1221. {isPending && (
  1222. <span className="text-xs px-1.5 py-0.5 rounded"
  1223. style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
  1224. Pending approval
  1225. </span>
  1226. )}
  1227. {isResolved && (
  1228. <span className="text-xs px-1.5 py-0.5 rounded"
  1229. style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
  1230. Approved
  1231. </span>
  1232. )}
  1233. {isResolved && comment.resolvedBy && (
  1234. <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  1235. by {comment.resolvedBy.name}
  1236. </span>
  1237. )}
  1238. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1239. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  1240. </span>
  1241. </div>
  1242. {/* Annotation preview badges */}
  1243. {annotations.length > 0 && (
  1244. <div className="flex flex-wrap gap-1 mb-2">
  1245. {annotations.map((ann, i) => (
  1246. <div
  1247. key={i}
  1248. className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
  1249. style={{ background: `${ann.color}20`, color: ann.color, border: `1px solid ${ann.color}40` }}
  1250. >
  1251. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1252. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1253. </svg>
  1254. {ann.type}
  1255. {isOwner && (
  1256. <button
  1257. onClick={() => {
  1258. const remaining = annotations.filter((_, j) => j !== i);
  1259. onDeleteAnnotation(remaining);
  1260. }}
  1261. className="ml-0.5 hover:opacity-70 transition-opacity"
  1262. title="Delete this annotation"
  1263. >
  1264. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1265. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1266. </svg>
  1267. </button>
  1268. )}
  1269. </div>
  1270. ))}
  1271. </div>
  1272. )}
  1273. {/* Content */}
  1274. <p className="text-[13px] sm:text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
  1275. {comment.content}
  1276. </p>
  1277. {/* Actions */}
  1278. <div className="flex items-center gap-1">
  1279. {/* Restore button for soft-deleted comments — project owner/ADMIN only */}
  1280. {canRestore && (
  1281. <button
  1282. onClick={() => onRestore(comment.id)}
  1283. className="text-xs px-2 py-1 rounded-md transition-colors"
  1284. style={{ color: '#86EFAC', background: 'rgba(34,197,94,0.10)' }}
  1285. title="Restore this comment"
  1286. >
  1287. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1288. <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
  1289. </svg>
  1290. Restore
  1291. </button>
  1292. )}
  1293. {!isReply && !isDeleted && (
  1294. <button
  1295. onClick={onAddAnnotation}
  1296. disabled={!canAddMore}
  1297. className="text-xs px-2 py-1 rounded-md transition-colors disabled:opacity-30"
  1298. style={{ color: '#818CF8' }}
  1299. title={canAddMore ? `Add annotation (${annotations.length}/${MAX_ANNOTATIONS})` : `Max ${MAX_ANNOTATIONS} annotations reached`}
  1300. >
  1301. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1302. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1303. </svg>
  1304. </button>
  1305. )}
  1306. {!isReply && !isDeleted && (
  1307. <button
  1308. onClick={onReply}
  1309. className="text-xs px-2 py-1 rounded-md transition-colors"
  1310. style={{ color: 'var(--text-muted)' }}
  1311. title="Reply"
  1312. >
  1313. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1314. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1315. </svg>
  1316. </button>
  1317. )}
  1318. {!isReply && (
  1319. <button
  1320. onClick={onReply}
  1321. className="text-xs px-2 py-1 rounded-md transition-colors"
  1322. style={{ color: 'var(--text-muted)' }}
  1323. title="Reply"
  1324. >
  1325. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1326. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1327. </svg>
  1328. </button>
  1329. )}
  1330. {/* Resolve / approval workflow buttons */}
  1331. {!isReply && !isDeleted && !isResolved && !isPending && (
  1332. <>
  1333. {canRequest ? (
  1334. <button
  1335. onClick={onRequestResolve}
  1336. className="text-xs px-2 py-1 rounded-md transition-colors"
  1337. style={{ color: '#6366F1' }}
  1338. title="Request resolve approval"
  1339. >
  1340. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1341. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1342. </svg>
  1343. Request resolve
  1344. </button>
  1345. ) : (
  1346. <span
  1347. className="text-xs px-2 py-1 opacity-30"
  1348. style={{ color: '#6366F1' }}
  1349. title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
  1350. >
  1351. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1352. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1353. </svg>
  1354. Request resolve
  1355. </span>
  1356. )}
  1357. </>
  1358. )}
  1359. {isPending && canApprove && !isReply && !isDeleted && (
  1360. <>
  1361. <button
  1362. onClick={() => onResolve('approve')}
  1363. className="text-xs px-2 py-1 rounded-md transition-colors"
  1364. style={{ color: '#86EFAC' }}
  1365. title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
  1366. >
  1367. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1368. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1369. </svg>
  1370. Approve
  1371. </button>
  1372. <button
  1373. onClick={() => onResolve('reject')}
  1374. className="text-xs px-2 py-1 rounded-md transition-colors"
  1375. style={{ color: '#FCA5A5' }}
  1376. title="Reject resolve request"
  1377. >
  1378. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1379. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1380. </svg>
  1381. Reject
  1382. </button>
  1383. </>
  1384. )}
  1385. {isPending && !canApprove && !isReply && !isDeleted && (
  1386. <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
  1387. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1388. <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  1389. </svg>
  1390. Awaiting approval
  1391. </span>
  1392. )}
  1393. {canReopen && !isReply && !isDeleted && (
  1394. <button
  1395. onClick={() => onResolve('reject')}
  1396. className="text-xs px-2 py-1 rounded-md transition-colors"
  1397. style={{ color: '#86EFAC' }}
  1398. title="Reopen comment"
  1399. >
  1400. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1401. <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
  1402. </svg>
  1403. Reopen
  1404. </button>
  1405. )}
  1406. {isOwner && !isDeleted && (
  1407. <button
  1408. onClick={onDeleteSelf}
  1409. className="text-xs px-2 py-1 rounded-md transition-colors"
  1410. style={{ color: 'var(--text-subtle)' }}
  1411. title="Hide comment"
  1412. >
  1413. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1414. <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
  1415. </svg>
  1416. </button>
  1417. )}
  1418. </div>
  1419. {/* Replies */}
  1420. {comment.replies && comment.replies.length > 0 && (
  1421. <div className="mt-3 space-y-3">
  1422. {comment.replies.map(reply => (
  1423. <ReplyItem
  1424. key={reply.id}
  1425. comment={reply}
  1426. isOwner={reply.userId === currentUserId}
  1427. onDelete={() => onDelete(reply.id)}
  1428. />
  1429. ))}
  1430. </div>
  1431. )}
  1432. </div>
  1433. </div>
  1434. </div>
  1435. );
  1436. }
  1437. // ── ReplyItem ──────────────────────────────────────────────────────────────
  1438. // Replies have no resolve, no annotation, no timestamp — just content + delete
  1439. function ReplyItem({
  1440. comment,
  1441. isOwner,
  1442. onDelete,
  1443. }: {
  1444. comment: Comment;
  1445. isOwner: boolean;
  1446. onDelete: (id: string) => void;
  1447. }) {
  1448. return (
  1449. <div className="flex gap-2.5 animate-fade-in">
  1450. <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="sm" />
  1451. <div className="flex-1 min-w-0">
  1452. <div className="flex items-center gap-2 mb-0.5">
  1453. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  1454. {comment.user?.name ?? 'Unknown'}
  1455. </span>
  1456. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1457. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  1458. </span>
  1459. </div>
  1460. <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  1461. {comment.content}
  1462. </p>
  1463. {isOwner && (
  1464. <button
  1465. onClick={() => onDelete(comment.id)}
  1466. className="text-xs mt-1 transition-colors"
  1467. style={{ color: 'var(--text-subtle)' }}
  1468. title="Delete reply"
  1469. >
  1470. Delete
  1471. </button>
  1472. )}
  1473. </div>
  1474. </div>
  1475. );
  1476. }